<?php if (!defined('CORE_DIR')) die('Wrong entry point!');

/* 
 * Copyright 2010 Anton Muraviev a.k.a kuromaisu
 * 
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *     http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * 
 */


// =============================================================================
// Section: DOM Parser
// =============================================================================

Aero::define('AeroDOMDocument', AS_CLASS);

/* -----------------------------------------------------------------------------
 * Class: AeroDOMDocument
 * -----------------------------------------------------------------------------
 *
 */
class AeroDOMDocument extends DOMDocument
{
	protected $document_errors = array();
	
	/*
	 * Constructor: __construct
	 */
	public function __construct($parameters = array())
	{
		parent::__construct();
		
		if (isset($parameters['html']))
		{
			$this->loadHTML($parameters['html'], isset($parameters['encoding']) ? $parameters['encoding'] : 'UTF-8');
		}
	}
	
	/*
	 * Method: loadHTML
	 * 
	 * This method fixes parent's some shortcomings.
	 * 
	 * Parameters:
	 * 	string	-	html code to load
	 * 	string	-	encoding used by html document
	 */
	public function loadHTML($html, $encoding = 'UTF-8')
	{
		$html = mb_convert_encoding($html, 'HTML-ENTITIES', $encoding);
		
		// Reser error array
		$this->document_errors = array();
		
		// Set doctype if it's missing...
		if (strpos($html, '<!DOCTYPE') === false)
		{
			if (strpos($html, '/>') === false)
			{
				$html = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">'.$html;
			}
			else
			{
				$html = '<!DOCTYPE html>'.$html; // Yeap, HTML5 doctype, baby!
			}
		}
		
		// Catch any DOMDocument errors and store then in an array
		set_error_handler(array($this,'handleInternalErrors'),E_ALL);
		
		parent::loadHTML($html);
		
		restore_error_handler();
	}
	
	/*
	 * Method: documentErrors
	 * 
	 * Returns an array of parsing errors for the current document.
	 * 
	 * If severity is not specified, an array for each severity level will be returned.
	 * 
	 * Parameters:
	 * 	int	-	(optional) error severity level (e.g. E_USER_ERROR, E_USER_NOTICE, etc)
	 * 
	 * Returns:
	 * 	array	-	document errors.
	 */
	public function documentErrors($severity = null)
	{
		if (!is_null($severity))
		{
			return isset($this->document_errors[$severity]) ? $this->document_errors[$severity] : array();
		}
		
		return $this->document_errors;
	}
	
	/*
	 * Method: handleInternalErrors
	 * 
	 * This method should be in the private scope, and should never be called manually.
	 */
	public function handleInternalErrors($errno, $errstr)
	{
		$this->document_errors[$errno][] = $errstr;
	}
	
	/*
	 * Method: getElementsBySelector
	 * 
	 * This method converts CSS selector into XPath and calls <getElementsByXPath>
	 * method.
	 * 
	 * For supported selectors see http://www.w3.org/TR/css3-selectors/#selectors
	 * Note that pseudo-elements, :lang(), link, and *-of-type pseudo-classes are
	 * not supported.
	 * 
	 * Parameters:
	 * 	string	-	CSS selector
	 * 
	 * Returns:
	 * 	DOMNodeList
	 */
	public function getElementsBySelector($selector)
	{
		$query = $this->cssSelectorToXPath($selector);
		
		return $this->getElementsByXPath($query);
	}
	
	/*
	 * Method: getElementsByXPath
	 * 
	 * Performs XPath query on the document.
	 * 
	 * Parameters:
	 * 	string	-	XPath query
	 * 
	 * Returns:
	 * 	DOMNodeList
	 */
	public function getElementsByXPath($query)
	{
		$xpath = new DOMXpath($this);
		
		return $xpath->query($query);
	}
	
	/*
	 * Method: cssSelectorToXPath
	 * 
	 * Coverts a CSS selector into XPath selector.
	 * 
	 * For supported selectors see http://www.w3.org/TR/css3-selectors/#selectors
	 * Note that pseudo-elements, :lang(), link, and *-of-type pseudo-classes are
	 * not supported.
	 * 
	 * Parameters:
	 * 	string	-	CSS selector
	 * 
	 * Returns:
	 * 	String with xpath statement.
	 */
	public function cssSelectorToXPath($selector)
	{
		$combined = '~'.
			'(\.|#)([\w-]+)|'. // id or class
			'\[(\w+)(?:([!*^$\~|]?=)["\']?(.*?)?["\']?)?\]|'. // [predicate]
			':([\w-]+)(?:\(["\']?(.*?)?["\']?\))?|'. // :pseudo-class(predicate)
			'\s*([+>\~\s])\s*(\w*|\*)'. // combinator + tag name, if present
			'~';
		
		preg_match_all($combined, ' '.$selector, $matches, PREG_SET_ORDER);
		
		$query = '/';
		
		foreach ($matches as $key => $match)
		{
			// TODO: Use better detection method
			switch (count($match))
			{
				/*
				 * Combinator and Tag name
				 */
				case 10:
					$combinator = $match[8];
					$tag = !empty($match[9]) ? $match[9] : '*';
					
					$query.= '/';
					
					if ($query == '//' && $combinator == ' ')
					{
						$query = '//'.$tag;
					}
					else switch ($combinator)
					{
						case ' ': 
							$query.= 'descendant::'.$tag;
							break;
						case '~':
							$query.= 'following-sibling::'.$tag.'[1]';
							break;
						case '+':
							$query.= 'following-sibling::*[1][name() = "'.$tag.'"]';
							break;
						case '>':
							$query.= 'child::'.$tag;
							break;
						default:
							trigger_error('Unknown selector combinator "'.$combinator.'" encountered!', E_USER_ERROR);
					}
					
					break;
				/*
				 * Id or class attribute
				 */
				case 3:
					$value = $match[2];
					
					if ($match[1] === '.')
					{
						$query.= '[contains(concat(" ",@class," ")," '.$value.' ")]';
					}
					else
					{
						$query.= '[@id="'.$value.'"]';
					}
					
					break;
				/*
				 * Attribute selector
				 */
				case 4:
				case 6: // with predicate
					$attribute = $match[3];
					$value = !empty($match[5]) ? $match[5] : '';
					$op = !empty($match[4]) ? $match[4] : '';
					
					$filter = '';
					
					switch ($op)
					{
						case '=':
							$filter = '@'.$attribute.'="'.$value.'"';
							break;
						case '*=':
							$filter = 'contains(@'.$attribute.',"'.$value.'")';
							break;
						case '^=':
							$filter = 'starts-with(@'.$attribute.',"'.$value.'")';
							break;
						case '$=':
							$filter = 'ends-with(@'.$attribute.',"'.$value.'")';
							break;
						case '~=':
							$filter = 'contains(concat(" ",@'.$attribute.'," ")," '.$value.' ")';
							break;
						case '|=':
							$filter = 'starts-with(@'.$attribute.',"'.$value.'-")';
							break;
						default:
							$filter = '@'.$attribute;
					}
					
					$query.= '['.$filter.']';
					
					break;
				/*
				 * Pseudo class
				 */
				case 7:
				case 8: // with predicate
					$pseudo = $match[6];
					$predicate = !empty($match[7]) ? $match[7] : '';
					
					switch ($pseudo)
					{
						case 'first-child':
							$query.= '[position() = 1]';
							break;
						case 'last-child':
							$query.= '[position() = last()]';
							break;
						case 'only-child':
							$query.= '[count(../*) = 1]';
							break;
						case 'empty':
							$query.= '[count(*) = 0]';
							break;
						case 'enabled':
							$query.= '[not(@disabled = "") and not(@disabled = "disabled")]';
							break;
						case 'disabled':
							$query.= '[@disabled = "" or @disabled = "disabled"]';
							break;
						case 'checked':
							$query.= '[@checked = "" or @checked = "checked"]';
							break;
						case 'not':
						case 'root':
						case 'nth-child':
						case 'nth-last-child':
							// TODO: implement remaining pseudo classes.
						case 'first-of-type':
						case 'nth-of-type':
						case 'only-of-type':
						case 'last-of-type':
						case 'nth-last-of-type':
							// FIXME: I could think of no method to emulate *-of-type using XPath
							// Need to do some manual sorting filtering
						default:
							trigger_error('Unsupported pseudo class: "'.$match[0].'"', E_USER_ERROR);
					}
					
					
					break;
				default:
					trigger_error('Malformed CSS selector string'.(!empty($match[0]) ? ': "'.$match[0].'"' : '.'), E_USER_ERROR);
			}
		}
		
		return $query;
	}
}